深入理解jvm02 - java内存区域与内存泄露异常
条评论本系列博客为《深入理解Java虚拟机 - JVM高级特性与最佳实践》读书笔记。本书大量干货,适合初学jvm的人员,也适合为应付面试人员,比较推荐的一本书。本系列只为记录书中精髓,方便查阅与记忆。如有错误,欢迎指出 O(∩_∩)O
运行时数据区域
在启动java程序时,我们会给jvm分配指定的内存大小。而jvm则对该部分内存划分为若干区域。其中有的区域以来用户线程的启动和结束而建立和销毁,有的区域随jvm进程的启动而存在。
线程私有:
- 程序计数器
- java虚拟机栈
- 本地方法栈
线程共享:
- java堆
- 方法区
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,可以看作当前线程所执行的字节码的行号指示器。jvm规范中唯一一个没有OutOfMemoryError异常的区域。该区域属于线程私有,随线程的启动和结束而建立和销毁。
字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都一来该计数器来完成。
java虚拟机栈
java虚拟机栈(Java Virtual Machine Stacks)描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。该区域属于线程私有。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
这个区域会抛出两个异常
- 线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
- 如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
Native方法常用于:
- 调用一些不是java语言写的代码
- 在java语言中直接操作计算机硬件
异常:StackOverflowError、OutOfMemoryError
java堆
Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。注意:线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。所以它是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。
Java堆还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
如果java堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
方法区
方法区(Method Area)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。。所以它也是线程共享的内存区域。
方法区还有另一个名字“永久代”(Permanent Generation)。这个名字是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,使用“永久代”这个概念来描述方法区的内存回收。对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。J9和JRockit只要没有触碰到进程可用内存的上限,就不会出现像HotSpot一样因为方法区发小不够而抛出OutOfMemoryError的异常。
垃圾收集行为在该区域比较少出现,这也印证了永久代这个名字,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError。
NIO通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能避免了在Java堆和Native堆中来回复制数据,以提高显著的性能。
直接内存的分配不会受到Java堆大小的限制,只会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。如果使用了直接内存,应注意它将会占用了本机总内存,会导致jvm操作的内存扩容时申请不到-Xmx大小的内存,而抛出OutOfMemoryError异常。
HotSpot虚拟机对象探秘
对象的创建
类检查
对象的创建(包含克隆、反序列化)通常new关键字。虚拟机遇到一条new指令时,先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查该符号引用代表的类是否被加载、解析、初始化。
内存分配
在类检查通过后,接下来虚拟机将为新生对象分配内存,分配内存存在两种方式:
- “指针碰撞”(Bump the Pointer)
要求堆内存绝对规整,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。 - “空闲列表”(Free List)
已使用的内存和空闲的内存相互交错,就只能使用空闲列表。它需要维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。例如:Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
划分可用空间之外,jvm还需要保证内存分配的并发问题,例如:给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
- CAS配上失败重试
- 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
每个线程在Java堆中预先分配一小块内存,哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定内存。
jvm实际上采用CAS配上失败重试的方式进行实现,如需使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
初始化
初始化,也分为两步,首先初始化内存为零值(不包括对象头),以保证该字段在Java代码中可以不赋初始值就直接使用(重排序)。
第二步,则是对对象进行必要的设置,其中包含这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。书中有给出HotSpot虚拟机bytecodeInterpreter.cpp中的代码片段,以印证上述内容。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头存放两部分数据,一部分被称为Mark Word,另一部分则是类型指针。
- Mark Word
数据长度为32bit或者64bit别分对应32位和64位的虚拟机。其中保存了哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。 - 类型指针
即对象指向它的类元数据的指针,通过这个指针来确定这个对象是哪个类的实例
实例数据是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容(包含继承父类)。
齐填充不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
对象访问是通过栈上的reference数据来操作堆上的具体对象的。目前主流的访问方式有使用句柄和直接指针两种。
- 句柄访问,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。优势:reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
- 直接指针访问,reference中存储的直接就是对象地址。优势:节省了一次指针定位的时间开销,速度更快。
HotSpot而言,它是使用直接指针访问方式进行对象访问的。
OutOfMemoryError异常
java堆溢出
Java堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
1 | java.lang.OutOfMemoryError:Java heap space |
异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heap space”,则是指java堆异常。
解决这个区域的异常,先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。内存溢出增加机器配置即可,内存泄露则需要一步步分析定位问题。区分两者的重点是确认内存中的对象是否是必要的。一般的手段是先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。查看泄露的对象到GC Root的引用链,一般就能比较准确的定位泄露代码的位置。
虚拟机栈和本地方法栈溢出
该区域,java规范了两个StackOverflowError、OutOfMemoryError。书中实验只验证了StackOverflowError。通过两种方式:
- 使用-Xss参数减少栈内存容量,栈深度相应减少。抛出StackOverflowError异常。
- 定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出StackOverflowError。
1 | Exception in thread"main"java.lang.StackOverflowError |
解决这个区域的异常,StackOverflowError相对还是简单的,因为日志中会打印异常栈。对于OutOfMemoryError,书中指出如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
方法区溢出
方法区,存放Class相关信息和运行时常量池,书中从这两个方面分别给出测试案列,使之抛出OutOfMemoryError。
- 使用String.intern(),向运行时常量池中存放字符串(仅限jdk1.6,jdk1.7相同字符串只会缓存第一次出现的对象)
- 使用CGLib创建大量的动态类
1 | Exception in thread"main"java.lang.OutOfMemoryError:PermGen space |
运行时常量池溢出,在OutOfMemoryError后面跟随的提示信息是“PermGen space”,说明运行时常量池属于方法区(HotSpot虚拟机中的永久代)的一部分。
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了CGLib字节码增强和动态语言之外,常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
本机直接内存溢出
使用Unsafe实例的allocateMemory()方法进行内存分配,直到抛出异常
1 | Exception in thread"main"java.lang.OutOfMemoryError |
由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。
jvm参数
-Xmx 最大堆大小
-Xms 初始堆大小
-XX:PermSize 永久代大小
-XX:MaxPermSize 永久代大小
-XX:+/-UseTLAB 使用本地线程分配缓冲
-Xss 栈大小
-XX: MaxDirectMemorySize 直接内存(默认和-Xmx相同)
- 本文链接:https://www.ofcoder.com/2020/09/05/java/jvm/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3jvm02%20-%20java%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F%E4%B8%8E%E5%86%85%E5%AD%98%E6%B3%84%E9%9C%B2%E5%BC%82%E5%B8%B8/
- 版权声明:Copyright © 并发笔记 - ofcoder.com. Author by far.
分享